banner
Fei_xiangShi

FXLOG

你在这里发现了我, 说明了什么呢?

Rust winit Iced wayland 应用最小化

起因:Wayland 上没有 set_visible#

在开发 Rustle(自己的一个音乐软件)时,我需要实现 "最小化到系统托盘" 功能:

  • 点击关闭按钮时,窗口隐藏而不是退出程序
  • 点击托盘图标时,窗口重新显示
  • 程序在后台继续运行(daemon 模式)

虽然使用 daemon 模式可以做到后台运行,但是 Iced 和 winit 的默认策略是关掉整个窗口,再需要时再唤出,但是由于我打算使用 GPU 渲染而不是软渲染,所以创建一个新的 GPU 上下文再重新初始化 vulkan 生命周期再调用 wgpu 绘制软件界面,这个冷启动的过程足足有 500ms!所以必须保存 vulkan/opengl 生命周期,则不能销毁窗口,而是令其不可见。

在 X11 上,这很简单 —— 调用 window.set_visible(false/true) 即可。但在 Wayland 上:

// winit 的 Wayland 实现
pub fn set_visible(&self, _visible: bool) {
    // Not possible on Wayland.
}

winit 直接放弃了这个功能,注释写着 "Wayland 上不可能实现"。(?怎么可能)

查阅 Wayland 协议文档后发现,Wayland 的设计哲学与 X11 截然不同:

  • 没有全局窗口管理器 API:客户端不能直接操作窗口的显示状态
  • Compositor 主导一切:窗口的显示、隐藏、位置都由 compositor 决定
  • 只有 set_minimized:但这个操作是单向的—— 程序无法通过代码恢复最小化的窗口

翻遍全网也没找到一样的问题。但真的没有办法吗?


探索:GTK、Chromium 是怎么做的?#

GTK 的实现#

查看 GTK 源码发现了关键线索:

// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
    GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);
    
    wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
    wl_surface_commit(impl->display_server.wl_surface);
    _gdk_window_clear_update_area(window);
}

关键发现:GTK 通过 wl_surface_attach(NULL) 来隐藏窗口!

XDG Shell 协议规范#

查阅 XDG Shell 协议文档,找到了官方说明:

Attaching a null buffer to a toplevel unmaps the surface.

The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.

这意味着:

  • 隐藏attach(NULL) + commit() → surface 被 unmap
  • 显示commit() → 触发 configure event → 重新渲染

Chromium 的实现#

进一步研究 Chromium 的 Wayland 实现:

// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
    shell_toplevel_.reset();  // 销毁 xdg_toplevel
    connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}

// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
    if (!CreateShellToplevel()) { ... }  // 重新创建 xdg_toplevel
}

Chromium 采用了更激进的方案 —— 销毁并重建 xdg_toplevel。但我后来发现这种方式在 Hyprland 上会导致 compositor 崩溃(这对吗?)。


实现:修改 winit#

架构概览#

┌────────────────────────────┐
│                    Rustle (应用层)                     │
├────────────────────────────┤
│                    iced (GUI 框架)                     │
├────────────────────────────┤  
│                 iced_winit (窗口管理)                  │
├────────────────────────────┤
│                    winit (窗口抽象)                    │
├────────────────────────────┤  
│         smithay-client-toolkit (Wayland 封装)          │
├────────────────────────────┤
│              wayland-client (协议绑定)                 │
├────────────────────────────┤
│                  Wayland Compositor                    │
└────────────────────────────┘

需要修改的层:

  1. iced: 添加 set_visible API
  2. winit: 实现 Wayland 上的 set_visible

winit 的修改#

核心实现src/platform_impl/linux/wayland/window/mod.rs):

pub fn set_visible(&self, visible: bool) {
    // 根据 XDG Shell 协议:
    // - "Attaching a null buffer to a toplevel unmaps the surface."
    // - "The client can re-map the toplevel by performing a commit without any
    //    buffer attached, waiting for a configure event and handling it as usual."

    let surface = self.window.wl_surface();

    if visible {
        {
            let mut state = self.window_state.lock().unwrap();
            state.set_visible(true);
            // 重置 frame callback 状态,打破死锁
            state.frame_callback_reset();
        }

        surface.commit();
        self.request_redraw();
    } else {
        self.window_state.lock().unwrap().set_visible(false);
        
        // 清空待处理的 redraw 请求
        self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

        // 按协议 unmap:attach(NULL) + commit
        surface.attach(None, 0, 0);
        surface.commit();
    }
}

iced 的修改#

添加 Actionruntime/src/window.rs):

pub enum Action {
    // ...existing actions...
    
    /// Set the visibility of the window.
    SetVisible(Id, bool),
}

/// Sets the visibility of the window.
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
    task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}

处理 Actionwinit/src/lib.rs):

window::Action::SetVisible(id, visible) => {
    if let Some(window) = window_manager.get_mut(id) {
        window.raw.set_visible(visible);
    }
}

不是哥们:那些令人头疼的 Bug#

Hyprland Compositor 崩溃(为什么不能销毁 xdg_toplevel?)#

最初的方案:参考 Chromium 的实现,销毁 xdg_toplevel 来隐藏窗口,重建它来显示窗口。

问题:在 Hyprland 上,销毁并重建 xdg_toplevel 会导致 compositor 崩溃,回到 SDDM 界面

// Hyprland 崩溃堆栈
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init  // 崩溃点

根本原因:Hyprland 不能正确处理在同一个 xdg_surface 上重新创建 xdg_toplevel 的情况。

最终方案:完全避免销毁 xdg_toplevel,只使用 XDG Shell 协议规定的 wl_surface.attach(NULL) 方法:

  • 隐藏:attach(NULL) + commit() → surface 被 unmap
  • 显示:commit() + request_redraw() → 重新渲染

这个方案:

  1. 完全符合 XDG Shell 协议
  2. 不破坏 xdg_toplevel 生命周期
  3. 兼容所有 compositor(包括 Hyprland)
  4. 代码更简洁,不需要复杂的生命周期管理

隐藏后无法恢复显示#

现象set_visible(false) 成功隐藏窗口,但 set_visible(true) 后窗口不出现。

原因:Frame callback 死锁。

┌───────────────────────────┐
│  wgpu 等待 frame callback 才能提交 buffer            │
│                        ↓                            │
│  compositor 等待 buffer 才能发送 frame callback      │
│                        ↓                            │
│                       死锁!                         │
└───────────────────────────┘

当窗口隐藏(attach(NULL))后,compositor 不再发送 frame callback。但 winit 的渲染循环依赖 frame callback 来知道何时渲染下一帧。

解决方案:在 set_visible(true) 时重置 frame callback 状态:

state.frame_callback_reset();  // 重置为 None,允许立即重绘

隐藏时窗口闪烁#

现象set_visible(false) 时窗口消失后又闪现一次,而且每次闪现的次数会累积。

原因:Client-Side Decorations (CSD) 的刷新逻辑。

winit 的事件循环会周期性调用 refresh_frame() 来更新窗口装饰。即使窗口已经隐藏,如果 CSD 框架认为自己是 "dirty" 的,它仍然会触发重绘 —— 这会重新 attach buffer,导致窗口又出现。

解决方案:多层防护:

// 1. refresh_frame() 中检查 visible
pub fn refresh_frame(&mut self) -> bool {
    if !self.visible {
        return false;  // 隐藏时不刷新装饰
    }
    // ...
}

// 2. request_redraw() 中检查 visible
pub fn request_redraw(&self) {
    if !self.window_state.lock().unwrap().visible() {
        return;  // 隐藏时不请求重绘
    }
    // ...
}

// 3. set_visible(false) 时清空 pending redraw
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

// 4. event loop 派发时检查 visible
if !window.visible() {
    window_requests.get(window_id).unwrap().take_redraw_requested();
    return None;  // 不派发 RedrawRequested
}

最终方案总结#

核心原理#

隐藏窗口:
┌────────────────────────┐
│ 1. set_visible(false)                          │
│ 2. 设置 visible 状态为 false                   │
│ 3. 清空 pending redraw 请求                    │
│ 4. wl_surface.attach(NULL, 0, 0)               │
│ 5. wl_surface.commit()                         │
│ → Surface 被 unmap,compositor 不再显示它     │
└────────────────────────┘

显示窗口:
┌────────────────────────┐
│ 1. set_visible(true)                           │
│ 2. 设置 visible 状态为 true                    │
│ 3. 重置 frame_callback_state (打破死锁)        │
│ 4. wl_surface.commit()                         │
│ 5. request_redraw()                            │
│ → 触发重绘,wgpu attach buffer,窗口重新出现  │
└────────────────────────┘

修改的文件#

项目文件修改内容
winitsrc/.../wayland/window/mod.rs实现 set_visible();在 request_redraw() 中检查 visible
winitsrc/.../wayland/window/state.rs添加 visible 字段;在 refresh_frame() 中检查 visible
winitsrc/.../wayland/event_loop/mod.rs在 RedrawRequested 派发前检查 visible 并清空 pending redraw
icedruntime/src/window.rs添加 SetVisible action 和 set_visible() 函数
icedwinit/src/lib.rs处理 SetVisible action,调用 window.raw.set_visible()

使用方式#

// 在 iced 应用中
Message::ToggleWindow => {
    self.window_hidden = !self.window_hidden;
    let visible = !self.window_hidden;
    
    return iced::window::latest().and_then(move |id| {
        iced::window::set_visible(id, visible)
    });
}

参考资料#

协议文档#

源码参考#

相关 Issue#


作者注:本实现基于 winit 0.30.12、iced 0.14.0。不同版本可能需要调整。

代码仓库:

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。